Мобильные приложения - Анализ поведения пользователей в мобильном приложении по перепродаже вещей¶

Цели исследования:

  • Управление вовлеченностью клиентов путем адаптации приложения под аудитории (целевую и смежную) на основе данных о поведении пользователей;
  • Получить гипотезы об улучшении приложения с точки зрения пользовательского опыта на основе поведения пользователей.

Задачи:

  • Проанализировать связь целевого события - просмотра контактов - и других действий пользователей:

    • В разрезе сессий отобрать сценарии, которые приводят к просмотру контактов;
    • Построить воронки по основным сценариям в разрезе уникальных пользователей
  • Оценить, какие действия чаще всего совершают те пользователи, которые просматривают контакты:

    • Рассчитать относительную частоту событий в разрезе двух групп пользователей:
      • группа пользователей, которые смотрели контакты contacts_show;
      • группа пользователей, которые не смотрели контакты contacts_show;
  • Проверить статистические гипотезы:

    • 1я Гипотеза: конверсия в просмотры контактов различается у группы пользователей, которые совершают действия tips_show и tips_click и группы, которая совершает только tips_show;
    • 2я Гипотеза: конверсия в просмотры контактов различается у группы пользователей, которые совершили advert_open и photos_show и группы, которая совершила только advert_open и не совершила photos_show.
  • По результатам исследования подготовить презентацию.

Ход работы:

  • Подключение необходимых библиотек;
  • Считать данные из файлов;
  • Предобработка данных:
    • Проверить наименования столбцов, типы данных, пропуски, наличие дубликатов и необходимость добавления новых столбцов в таблицу (например, с датой);
    • Проверить размер таблицы до и после удаления данных, если это будет необходимо;
  • Исследовательский анализ данных:
    • Изучить за какой период представлены данные;
    • Изучить сколько всего уникальных пользователей;
    • Изучить какие события совершают пользователи и узнать частоту их встречаемости;
  • Сценарии пользователей и воронки вовлечения до целевого действия:
    • Найти длительность сессии пользователей;
    • Рассчитать процентные доли каждого совершаемого события в разрезе каждой из групп: совершивших contacts_show и не совершивших contacts_show, и найти, если есть, закономерность между этими группами;
    • Найти популярные сценарии пользователей: построить диаграмму по сессиям и найти 3-4 популярных сценария пользователей до совершения целевого действия;
    • Построить воронки наиболее популярных сценариев до совершения целевого действия по уникальным пользователям;
  • Проверка гипотез:
    • Проверить 1ю гипотезу;
    • Проверить 2ю гипотезу;
  • Выводы и рекоммендации.

Подключение библиотек¶

Подключим необходимые для работы библиотеки.

In [1]:
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.graph_objects as go
import numpy as np
import requests
from tqdm.auto import tqdm
import math as mth
from scipy import stats as st

Считывание данных¶

Считаем данные о пользователях приложения по перепродаже вещей из файлов и выведем их содержимое на экран. Сразу при считывании файла преобразуем формат даты и времени в формат datetime.

In [2]:
data = pd.read_csv('dataset/mobile_dataset.csv', parse_dates=['event.time'])
sources = pd.read_csv('dataset/mobile_sources.csv')
In [3]:
display(data.head(10).style.set_caption("Содержимое датафрейма mobile_dataset.csv"))
data.info()
Содержимое датафрейма mobile_dataset.csv
  event.time event.name user.id
0 2019-10-07 00:00:00.431357 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
1 2019-10-07 00:00:01.236320 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
2 2019-10-07 00:00:02.245341 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c
3 2019-10-07 00:00:07.039334 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
4 2019-10-07 00:00:56.319813 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c
5 2019-10-07 00:01:19.993624 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c
6 2019-10-07 00:01:27.770232 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
7 2019-10-07 00:01:34.804591 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
8 2019-10-07 00:01:49.732803 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c
9 2019-10-07 00:01:54.958298 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 74197 entries, 0 to 74196
Data columns (total 3 columns):
 #   Column      Non-Null Count  Dtype         
---  ------      --------------  -----         
 0   event.time  74197 non-null  datetime64[ns]
 1   event.name  74197 non-null  object        
 2   user.id     74197 non-null  object        
dtypes: datetime64[ns](1), object(2)
memory usage: 1.7+ MB
In [4]:
display(sources.head(10).style.set_caption("Содержимое датафрейма mobile_sources.csv"))
sources.info()
Содержимое датафрейма mobile_sources.csv
  userId source
0 020292ab-89bc-4156-9acf-68bc2783f894 other
1 cf7eda61-9349-469f-ac27-e5b6f5ec475c yandex
2 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 yandex
3 d9b06b47-0f36-419b-bbb0-3533e582a6cb other
4 f32e1e2a-3027-4693-b793-b7b3ff274439 google
5 17f6b2db-2964-4d11-89d8-7e38d2cb4750 yandex
6 62aa104f-592d-4ccb-8226-2ba0e719ded5 yandex
7 57321726-5d66-4d51-84f4-c797c35dcf2b google
8 c2cf55c0-95f7-4269-896c-931d14deaab5 google
9 48e614d6-fe03-40f7-bf9e-4c4f61c19f64 yandex
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4293 entries, 0 to 4292
Data columns (total 2 columns):
 #   Column  Non-Null Count  Dtype 
---  ------  --------------  ----- 
 0   userId  4293 non-null   object
 1   source  4293 non-null   object
dtypes: object(2)
memory usage: 67.2+ KB

При первичном изучении данных, заметим, что:

  • Данные не содержат пропусков, так как количество ненулевых элементов во всех столбцах одинаковое: для таблицы data - 74197, а для таблицы sources - 4293;
  • Названия столбцов не соответствуют snake_case, поэтому далее нужно будет привести названия столбцов в обеих таблицах к соответствующему виду;
  • Для удобства создадим дополнительный столбец типа datetime, в который занесем дату события event.time, также для корректной работы изменим тип столбца event.time на тип datetime, сейчас тип столбца - object;
  • На этапе предобработке необходимо будет проверить наличие дубликатов.

Предобработка данных¶

Приведем названия столбцов к соответствующему виду.

In [5]:
#Переименуем столбцы
data = data.rename(columns={'event.time': 'event_time', 'event.name': 'event_name', 'user.id': 'user_id'})
sources = sources.rename(columns={'userId': 'user_id'})
display(data.head(), sources.head())
event_time event_name user_id
0 2019-10-07 00:00:00.431357 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
1 2019-10-07 00:00:01.236320 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
2 2019-10-07 00:00:02.245341 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c
3 2019-10-07 00:00:07.039334 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
4 2019-10-07 00:00:56.319813 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c
user_id source
0 020292ab-89bc-4156-9acf-68bc2783f894 other
1 cf7eda61-9349-469f-ac27-e5b6f5ec475c yandex
2 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 yandex
3 d9b06b47-0f36-419b-bbb0-3533e582a6cb other
4 f32e1e2a-3027-4693-b793-b7b3ff274439 google

Названия столбцов изменились в двух таблицах. Теперь изменим тип столбца с датой и временем события и создадим дополнительно столбец с отдельной датой события.

In [6]:
data['date'] = pd.to_datetime(data['event_time'].dt.date)
display(data.sample(5))
data.info()
event_time event_name user_id date
16980 2019-10-14 14:17:58.445899 photos_show 6383ff6a-04b8-4562-a98f-bb4f760d3c39 2019-10-14
60810 2019-10-29 21:49:30.603863 favorites_add cab083cd-103f-4181-8f1d-362792e6d058 2019-10-29
50015 2019-10-26 15:15:01.695025 tips_show 9cb149ed-5424-4c3c-82cd-4033f8ab7468 2019-10-26
70886 2019-11-02 20:19:31.963779 tips_click dd61b125-4b5f-4312-82dc-dcc431980265 2019-11-02
6605 2019-10-09 18:57:56.458239 tips_show da1c9773-59cb-43e4-a853-18221d924588 2019-10-09
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 74197 entries, 0 to 74196
Data columns (total 4 columns):
 #   Column      Non-Null Count  Dtype         
---  ------      --------------  -----         
 0   event_time  74197 non-null  datetime64[ns]
 1   event_name  74197 non-null  object        
 2   user_id     74197 non-null  object        
 3   date        74197 non-null  datetime64[ns]
dtypes: datetime64[ns](2), object(2)
memory usage: 2.3+ MB

Добавили новый столбец с датой совершения действия, изменили типо в столбце с датой и временем события, теперь тип данных этих двух столбцов - datetime64.

В таблице также имеются два события, которые обозначают одно и то же действие. Проверим каких из них больше по количеству и приведем события к одному виду для удобства и корректных расчетов в дальнейшем.

In [7]:
len(data.query('event_name == "contacts_show"'))
Out[7]:
4450
In [8]:
len(data.query('event_name == "show_contacts"'))
Out[8]:
79

Количество действий show_contacts значительно меньше, чем contacts_show. Поэтому заменим действие show_contacts на contacts_show.

In [9]:
data['event_name'] = data['event_name'].replace('show_contacts', 'contacts_show')
In [10]:
len(data.query('event_name == "contacts_show"'))
Out[10]:
4529
In [11]:
len(data.query('event_name == "show_contacts"'))
Out[11]:
0
In [12]:
data['event_name'].nunique()
Out[12]:
15

Заменили событие show_contacts на contacts_show. Проверили, что количество уникальных событий сократилось с 16 до 15 и в столбце event_name не осталось событий show_contacts.

Проверим количество пропущенных значений в столбцах.

In [13]:
display(data.isna().sum(), sources.isna().sum())
event_time    0
event_name    0
user_id       0
date          0
dtype: int64
user_id    0
source     0
dtype: int64

Пропуски в данных в двух таблицах отсутствуют. Проверим, есть ли в таблице полные дубликаты.

In [14]:
display(data.duplicated().sum(), sources.duplicated().sum()) 
0
0

Проверим неявные дубликаты.

In [15]:
dupl_data = data[data.duplicated(subset=['user_id', 'event_time'])]
dupl_data
Out[15]:
event_time event_name user_id date

Полные и неявные дубликаты в таблицах отсутствуют.

Посчитаем количество уникальных пользователей и сравних совпадает ли число уникальных пользователей в двух таблицах.

In [16]:
data['user_id'].nunique()
Out[16]:
4293
In [17]:
sources['user_id'].nunique()
Out[17]:
4293

Количество уникальных пользователей в таблицах совпадает.

Вывод:

  • Столбцы таблиц были переименованы в соответствие со стилем snake_case;
  • В столбцу event_time тип данных был заменен на datetime, был создан дополнительный столбез с датой события date типа datetime;
  • В столбце event_name была произведена замена события show_contacts на contacts_show для корректных расчетов в дальнейшем, так как данные события отвечают за одно и то же действие.
  • Была произведена проверка пропусков - пропуски в таблицах отсутсвовали;
  • Была произведена проверка на явные и не явные дубликаты - дубликаты в таблицах отсутствовали.

Исследовательский анализ данных¶

Изучим за какой период представлены данные в таблице.

In [18]:
print('Минимальная дата:', data['date'].min(), '\nМаксимальная дата', data['date'].max())
Минимальная дата: 2019-10-07 00:00:00 
Максимальная дата 2019-11-03 00:00:00

В таблице представлены данные с 7 октября 2019 года до 3 ноября 2019 года. Посмотрим как распределены данные по этому периоду. В какое время пользователи чаще пользуются приложением, а в какое реже.

In [19]:
plt.title('Распределение данных по дате и времени')
plt.xlabel('Дата')
plt.ylabel('Активность пользователей')
data['event_time'].hist(bins=100, figsize=(20, 5), ec="blue", fc="green", alpha=0.6, linewidth=2)

plt.show()

plt.title('Распределение данных по времени суток')
plt.xlabel('Время суток')
plt.ylabel('Активность пользователей')
data['event_time'].dt.hour.hist(bins=24, figsize=(20, 5),\
                                     ec="blue", fc="green", alpha=0.6, linewidth=2)
plt.xticks(range(0, 23))
plt.show()

На первом графике видим, что в дневное время активность пользователей выше, а в ночное меньше, за счет чего можем сказать что данные по дням распределены нормально. При этом активность пользователей каждый день примерно одинаковая. Нет таких периодов, когда в какой-то из дней активности не было совсем или активность все сутки была равномерной. Для анализа можем использовать данные за весь период.

На втором графике распределения активности по часам в сутках можем заметить, что наибольшая активность пользователей в приложении приходится на обеденное время, в период с 14 до 16 часов дня, а так же высокая активность пользователей вечером, около 20-21 вечера. Менее всего пользователи пользуются приложением в период с 3 ночи до 6 утра.

Проверим, сколько всего событий представлено в таблице, количество уникальных пользователей и сколько в среднем приходится событий на пользователя.

In [20]:
print('Всего событий:', data['event_name'].shape[0])
print('Всего уникальных видов событий:', data['event_name'].nunique())
print('Всего уникальных пользователей:', data['user_id'].nunique())
print('В среднем на пользователя приходится:', round(data.groupby('user_id')['event_name'].count().median()))
Всего событий: 74197
Всего уникальных видов событий: 15
Всего уникальных пользователей: 4293
В среднем на пользователя приходится: 9

Посмотрим более наглядно на то, сколько событий совершают пользователи.

In [21]:
event_by_user = data.groupby('user_id')['event_name'].count().reset_index()
event_by_user.columns = ['user_id', 'event_count']
print(round(event_by_user['event_count'].describe(), 2))

#Строим график распределения количества событий на пользователя
sns.displot(event_by_user['event_count'], bins=100, kde=True, height=7, )
plt.title('Распределение количества событий на пользователя')
plt.xlabel('Количество событий')
plt.ylabel('Количество пользователей')
plt.grid(which='major')
plt.show()
count    4293.00
mean       17.28
std        29.13
min         1.00
25%         5.00
50%         9.00
75%        17.00
max       478.00
Name: event_count, dtype: float64

Как видим, в среднем пользователь совершает около 9 событий. В данном случае медианное значение более приближено к реальности, так как у нас присутствуют выбросы в виде крупных значений: некоторые пользователи совершают практически по 478 событий.

Теперь посмотрим какие события совершают пользователи чаще, а какие реже. Найдем количество уникальных пользователей, которые совершают каждое из действий и найдем долю, которая покажет, какой процент уникальных пользователей совершил то или иное событие.

In [22]:
events = data.groupby('event_name')['user_id'].count().reset_index().sort_values(by='user_id', ascending=False)
users = data.groupby('event_name')['user_id'].nunique().reset_index().sort_values(by='user_id', ascending=False)
users['part'] = round(users['user_id'] / data['user_id'].nunique() * 100, 2) 
events = events.merge(users, on='event_name')
events.columns = ['Наименование события', 'Кол-во событий', 'Кол-во пользователей', 'Доля пользователей']

cm = sns.light_palette("lightblue", as_cmap=True)

display(events.style.background_gradient(cmap=cm)\
        .set_caption('Таблица с частотой встречаемости событий и долей уникальных пользователей, совершивших это событие'))

#Построим график показывающий частоту встречаемости событий
plt.figure(figsize=(17, 6))
sns.barplot(data=events, x='Кол-во событий', y='Наименование события', ec="blue", fc="green", alpha=0.6)
for i, v in enumerate(events['Кол-во событий']):
    plt.text(v+3000, i, str(round(v, 2)), ha = 'center', size = 12)
plt.title('Частота событий', fontsize=14)
plt.ylabel('Наименование событий', fontsize=14)
plt.xlabel('Частота встречаемости события', fontsize=14)
plt.show()
Таблица с частотой встречаемости событий и долей уникальных пользователей, совершивших это событие
  Наименование события Кол-во событий Кол-во пользователей Доля пользователей
0 tips_show 40055 2801 65.250000
1 photos_show 10012 1095 25.510000
2 advert_open 6164 751 17.490000
3 contacts_show 4529 981 22.850000
4 map 3881 1456 33.920000
5 search_1 3506 787 18.330000
6 favorites_add 1417 351 8.180000
7 search_5 1049 663 15.440000
8 tips_click 814 322 7.500000
9 search_4 701 474 11.040000
10 contacts_call 541 213 4.960000
11 search_3 522 208 4.850000
12 search_6 460 330 7.690000
13 search_2 324 242 5.640000
14 search_7 222 157 3.660000

Событие, которое совершают пользователи чаще всего - tips_show (увидел рекомендованное объявление). Если смотреть по количеству уникальных пользователей, которые совершили каждое из событий, то наибольшая доля приходится на событие tips_show, а также на событие map и photos_show. Именно эти три события совершает больший процент уникальных пользователей приложения.

Сценарии пользователей и воронки вовлечения до целевого действия¶

Средняя длительность сессии пользователя¶

Для нахождения длительности сессий пользователя необходимо будет отсортировать данные датафрейма по возрастанию по пользователям и времени совершения действий. Потом найти время тайм-аутов, когда пользователь был не активен в приложении, и после сможем уже вычислить длительность сессии.

In [23]:
#Отсортируем таблицу по пользователю и времени совершения события
data = data.sort_values(by=['user_id', 'event_time'], ascending=True)
data.head()
Out[23]:
event_time event_name user_id date
805 2019-10-07 13:39:45.989359 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07
806 2019-10-07 13:40:31.052909 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07
809 2019-10-07 13:41:05.722489 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07
820 2019-10-07 13:43:20.735461 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07
830 2019-10-07 13:45:30.917502 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07

Теперь можем найти время тайм-аутов можду сессиями пользователей, так как таблица отсортирована по возрастанию по времени и пользователям, то можем найти разницу во времени между совершением действий.

In [24]:
#Создадим датафрейм с тайм-аутами
timeout = pd.DataFrame()
timeout = data.groupby('user_id')['event_time'].diff().reset_index()
timeout = timeout.drop(columns='index')
#Добавим столбец с длиной тайм-аута в минутах
timeout['event_time_min'] = timeout['event_time'].dt.total_seconds()/60
timeout.head()
Out[24]:
event_time event_time_min
0 NaT NaN
1 0 days 00:00:45.063550 0.751059
2 0 days 00:00:34.669580 0.577826
3 0 days 00:02:15.012972 2.250216
4 0 days 00:02:10.182041 2.169701

Создали таблицу с тайм-аутами пользователей, также добавили в нее столбец с длительностью тайм-аута в минутах. Построим диаграмму размаха, чтобы определить какое время тайм-аута нам необходимо брать для нахождения длительности сессий.

In [25]:
#Построим диаграмму размаха
sns.boxplot(x=timeout['event_time_min'])
plt.title('Диаграмма размаха длительностей тайм-аута')
plt.xlabel('Длина тайм-аута в минутах')
plt.show()

В таблице есть слишком длинные тайм-ауды, отбросим их, чтобы посмотреть, где расположены 1 и 3 квартили и медиана.

In [26]:
sns.boxplot(x=timeout['event_time_min'],
            showfliers=False)

plt.title('Диаграмма размаха длительностей тайм-аута')
plt.xlabel('Длина тайм-аута в минутах')
plt.show()

Можем заметить, что большинство значений тайм-аута находится в пределах 7 минут.

In [27]:
print('Длительность таймаута по медиане: {} минуту'.format(round(timeout['event_time_min'].quantile(0.5))))
Длительность таймаута по медиане: 1 минуту

Построим график распределения длительности тайм-аутов пользователей.

In [28]:
timeout = timeout.query('event_time_min < event_time_min.quantile(0.9)')
sns.displot(timeout['event_time_min'], bins=100, kde=True, height=7, )
plt.title('Распределение длительности тайм-аута пользователя')
plt.xlabel('Длительность тайм-аута в минутах')
plt.ylabel('Частота встречаемости')
plt.grid(which='major')
plt.show()

Исходя из вышепредставленных графиков и вычислений среднее значение продолжительности тайм-аута равно 1 минуте, а продолжительность тайм-аута по квантилю от 90% равна 7 минутам. Возьмем значение 7 минут тайм-аута для последующих вычислений. Так как большое количество коротких тайм-аутов может говорить о том, что пользователи еще совершают действия в пределах одной сессии. Внутри сессии короткие тайм-ауты, а между сессий тайм-ауты длиннее. После 7 минут же количество тайм-аутов снижается и выравнивается.

In [29]:
#Делим действия пользователей по сессиям с промежутком в 7 минут
g = (data.groupby('user_id')['event_time'].diff() > pd.Timedelta('7Min')).cumsum()

#Добавим новый столбец с номером сессии 
data['session_id'] = data.groupby(['user_id', g], sort=False).ngroup() + 1
data.head().style.set_caption('Таблица пользователей с номерами сессий')
Out[29]:
Таблица пользователей с номерами сессий
  event_time event_name user_id date session_id
805 2019-10-07 13:39:45.989359 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 00:00:00 1
806 2019-10-07 13:40:31.052909 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 00:00:00 1
809 2019-10-07 13:41:05.722489 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 00:00:00 1
820 2019-10-07 13:43:20.735461 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 00:00:00 1
830 2019-10-07 13:45:30.917502 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 00:00:00 1

Найдем длительность сессии пользователей. Для этого сгруппируем датафрейм по сессиям и времени, после чего из времени последнего действия в сессии отнимаем время первого действия в этой же сессии и таким образом находим разницу - длительность сессии.

In [30]:
data['session_duration'] = data[data['session_id'].notnull()]\
.groupby('session_id')['event_time']\
.transform(lambda x: x.iat[-1] - x.iat[0])
In [31]:
data['session_duration'] = round(data['session_duration'].dt.total_seconds())
In [32]:
data.head()
Out[32]:
event_time event_name user_id date session_id session_duration
805 2019-10-07 13:39:45.989359 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 1 596.0
806 2019-10-07 13:40:31.052909 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 1 596.0
809 2019-10-07 13:41:05.722489 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 1 596.0
820 2019-10-07 13:43:20.735461 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 1 596.0
830 2019-10-07 13:45:30.917502 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 1 596.0
In [33]:
print('Средняя продолжительность длительности сессии пользователей: {} секунд или {:.0f} минут'\
      .format(round(data['session_duration'].median()), (round(data['session_duration'].median()))/60))

sns.displot(data.query('session_duration > 0')['session_duration'], bins=100, kde=True, height=7, )
plt.title('Распределение длительности сессии пользователя')
plt.xlabel('Длительность сессии в секундах')
plt.ylabel('Частота встречаемости')
plt.grid(which='major')
plt.show()
Средняя продолжительность длительности сессии пользователей: 635 секунд или 11 минут

Как видим средняя продолжительность сессии пользователей составляет 11 минут. В данном случае мы не учитываем длительность сессий равных 0, так как такая длительность сессий может быть если пользователя выкинуло из приложения, произошел сбой, или пользователь случайно открыл и закрыл приложение, не успев совершить в нем действия.

Относительная частота событий в разрезе двух групп пользователей¶

Рассчитаем процентные доли каждого совершаемого события в разрезе каждой из групп: совершивших contacts_show и не совершивших действие contacts_show. Проверим, есть ли закономерность между этими группами.

Для начала создадим две таблицы: в первую занесем пользователей, которые совершали contacts_show, а во вторую - которые не совершали данное действие.

In [34]:
#Создадим таблицу в которой будет user_id и его уникальные события
user_unique_events = pd.DataFrame()
user_unique_events = data.groupby('user_id')['event_name'].unique().reset_index()
user_unique_events
Out[34]:
user_id event_name
0 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 [tips_show, map]
1 00157779-810c-4498-9e05-a1e9e3cedf93 [search_1, photos_show, favorites_add, contact...
2 00463033-5717-4bf1-91b4-09183923b9df [photos_show]
3 004690c3-5a84-4bb7-a8af-e0c8f8fca64e [search_7, search_5, map, search_4, search_6, ...
4 00551e79-152e-4441-9cf7-565d7eb04090 [contacts_show, contacts_call, search_1, photo...
... ... ...
4288 ffab8d8a-30bb-424a-a3ab-0b63ebbf7b07 [map, tips_show]
4289 ffc01466-fdb1-4460-ae94-e800f52eb136 [photos_show, contacts_show]
4290 ffcf50d9-293c-4254-8243-4890b030b238 [tips_show, map]
4291 ffe68f10-e48e-470e-be9b-eeb93128ff1a [search_1, photos_show, contacts_show]
4292 fffb9e79-b927-4dbb-9b48-7fd09b23a62b [tips_show, map, contacts_show]

4293 rows × 2 columns

In [35]:
# Создадим две таблицы, в одной будут пользователи, совершившие contacts_show, в другой - не совершившие
user_do_contacts_show = pd.DataFrame()
user_no_do_contacts_show = pd.DataFrame()
user_contacts_show_tmp = user_unique_events.copy()

user_contacts_show_tmp['index'] = user_unique_events['event_name']\
                                .apply(lambda x: np.where(x=="contacts_show")).explode().explode()
user_do_contacts_show = user_contacts_show_tmp.query('index.notnull()')
user_no_do_contacts_show = user_contacts_show_tmp.query('index.isnull()')

#Удаляем промежуточные столбы и добавляем столбцы из оригинальной таблицы data
user_do_contacts_show = user_do_contacts_show.drop(columns=['event_name', 'index'])
user_do_contacts_show = user_do_contacts_show.merge(data, how='left', on='user_id')

user_no_do_contacts_show = user_no_do_contacts_show.drop(columns=['event_name', 'index'])
user_no_do_contacts_show = user_no_do_contacts_show.merge(data, how='left', on='user_id')
In [36]:
print('Кол-во уникальных пользователей, совершивших просмотр контактов', user_do_contacts_show['user_id'].nunique(),\
      '\nКол-во уникальныхпользователей, не совершивших просмотр контактов', user_no_do_contacts_show['user_id'].nunique())
Кол-во уникальных пользователей, совершивших просмотр контактов 981 
Кол-во уникальныхпользователей, не совершивших просмотр контактов 3312

Если сложить количество уникальных пользователей из этих двух таблиц, то заметим, что оно соответсвует количеству уникальных пользователей в оригинальной таблице data, а именно 4293.

In [37]:
#Вывод таблиц
display(user_do_contacts_show.head().style.set_caption('Таблица с пользователями, совершившими contacts_show'),\
       user_no_do_contacts_show.head().style.set_caption('Таблица с пользователями, не совершившими contacts_show'))
Таблица с пользователями, совершившими contacts_show
  user_id event_time event_name date session_id session_duration
0 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:34:33.849769 search_1 2019-10-19 00:00:00 5 739.000000
1 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:35:19.296599 search_1 2019-10-19 00:00:00 5 739.000000
2 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:36:44.344691 search_1 2019-10-19 00:00:00 5 739.000000
3 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:40:38.990477 photos_show 2019-10-19 00:00:00 5 739.000000
4 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:42:13.837523 photos_show 2019-10-19 00:00:00 5 739.000000
Таблица с пользователями, не совершившими contacts_show
  user_id event_time event_name date session_id session_duration
0 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 13:39:45.989359 tips_show 2019-10-07 00:00:00 1 596.000000
1 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 13:40:31.052909 tips_show 2019-10-07 00:00:00 1 596.000000
2 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 13:41:05.722489 tips_show 2019-10-07 00:00:00 1 596.000000
3 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 13:43:20.735461 tips_show 2019-10-07 00:00:00 1 596.000000
4 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 13:45:30.917502 tips_show 2019-10-07 00:00:00 1 596.000000

У нас получилось 2 таблицы в которых содержатся все действия пользователей, которые совершили действие - просмотр контактов, и которые не совершили действие - просмотр контактов.

In [38]:
def unique_events_df(df, col1, col2):
    """
    Функция берет датафрейм и оставляет в нем только те строки,
    в которых нет подряд повторяющихся значений в определенных столбцах.
    """
    sorted_df = df.loc[(df[[col1, col2]].shift() != df[[col1, col2]]).any(axis=1)]
    return sorted_df
In [39]:
user_do_contacts_show_unique_events = unique_events_df(user_do_contacts_show, 'session_id', 'event_name')
user_no_do_contacts_show_unique_events = unique_events_df(user_no_do_contacts_show, 'session_id', 'event_name')

display(user_do_contacts_show_unique_events.head().style\
    .set_caption('Таблица с пользователями, совершившими contacts_show и их уникальными событиями'),
     user_no_do_contacts_show_unique_events.head().style\
    .set_caption('Таблица с пользователями, не совершившими contacts_show и их уникальными событиями'))
Таблица с пользователями, совершившими contacts_show и их уникальными событиями
  user_id event_time event_name date session_id session_duration
0 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:34:33.849769 search_1 2019-10-19 00:00:00 5 739.000000
3 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:40:38.990477 photos_show 2019-10-19 00:00:00 5 739.000000
7 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:58:00.109019 search_1 2019-10-19 00:00:00 6 115.000000
8 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:59:54.637098 photos_show 2019-10-19 00:00:00 6 115.000000
9 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-20 18:49:24.115634 search_1 2019-10-20 00:00:00 7 0.000000
Таблица с пользователями, не совершившими contacts_show и их уникальными событиями
  user_id event_time event_name date session_id session_duration
0 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 13:39:45.989359 tips_show 2019-10-07 00:00:00 1 596.000000
9 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 18:33:55.577963 map 2019-10-09 00:00:00 2 507.000000
11 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 18:40:28.738785 tips_show 2019-10-09 00:00:00 2 507.000000
13 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 19:52:30.778932 tips_show 2019-10-21 00:00:00 3 899.000000
15 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 19:53:38.767230 map 2019-10-21 00:00:00 3 899.000000

Создали две таблицы: в одной пользователи, которые совершили событие contacts_show, во второй - не совершившие contacts_show и уникальные события пользователей.

Посчитаем относительные частоты встречаеющихся событий в этих таблицах.

In [40]:
#Функция посчитает долевое соотношение событий в группе
def rel_frequency(df, column_name):
    frequency = [(value, df.query('{} == "{}"'.format(column_name, value))\
                  .count()[column_name] / len(df)) for value in set(df[column_name])]
    return frequency
In [41]:
#Посчитаем долевые значения, которые занимают события в группе, совершившей contacts_show
contacts_show_users = rel_frequency(user_do_contacts_show, 'event_name')
rel_contacts_show = []
for rf in contacts_show_users:
    rel_contacts_show.append(rf[1])
print("Сумма долевых значений =", sum(rel_contacts_show))
contacts_show_users = pd.DataFrame(list(contacts_show_users))
contacts_show_users.columns = ['event_name', 'part']
contacts_show_users = contacts_show_users.sort_values(by='part', ascending=False)
contacts_show_users
Сумма долевых значений = 1.0
Out[41]:
event_name part
6 tips_show 0.469464
8 contacts_show 0.166526
9 photos_show 0.140751
7 advert_open 0.058426
14 search_1 0.049307
2 map 0.040482
3 contacts_call 0.019892
10 favorites_add 0.015590
13 tips_click 0.012244
1 search_5 0.009155
0 search_4 0.005479
4 search_3 0.005295
12 search_2 0.003530
5 search_6 0.002721
11 search_7 0.001140
In [42]:
#Посчитаем долевые значения, которые занимают события в группе, не совершившей contacts_show
no_contacts_show_users = rel_frequency(user_no_do_contacts_show, 'event_name')
rel_no_contacts_show = []
for rf in no_contacts_show_users:
    rel_no_contacts_show.append(rf[1])
print("Сумма долевых значений =", sum(rel_no_contacts_show))
no_contacts_show_users = pd.DataFrame(list(no_contacts_show_users))
no_contacts_show_users.columns = ['event_name', 'part']
no_contacts_show_users = no_contacts_show_users.sort_values(by='part', ascending=False)
no_contacts_show_users
Сумма долевых значений = 1.0
Out[42]:
event_name part
5 tips_show 0.580574
7 photos_show 0.131574
6 advert_open 0.097340
2 map 0.059149
12 search_1 0.046064
8 favorites_add 0.021128
1 search_5 0.017021
0 search_4 0.011745
11 tips_click 0.010234
3 search_6 0.008213
4 search_3 0.008043
10 search_2 0.004851
9 search_7 0.004064

Сформировали две таблицы, которые содержат названия событий, совершенные пользователями и относительную частоту этих событий. Для проверки в обоих случаях убеждаемся, что сумма долей равна 1.

Построим график соотношения этих долей в двух группах.

In [43]:
fig = go.Figure()
fig.add_trace(go.Bar(
    x=no_contacts_show_users['part'],
    y=no_contacts_show_users['event_name'],
    name='Группа не совершала contacts_show',
    orientation='h',
    marker=dict(
        color='rgba(233, 76, 76, 0.8)',
        line=dict(color='rgba(181, 0, 0, 0.8)', width=3)
    )
))
fig.add_trace(go.Bar(
    x=contacts_show_users['part'],
    y=contacts_show_users['event_name'],
    name='Группа совершала contacts_show',
    orientation='h',
    marker=dict(
        color='rgba(134, 228, 93, 0.8)',
        line=dict(color='rgba(53, 158, 8, 0.8)', width=3)
    )
))

fig.update_layout(barmode='stack',
                  width=950,
                  height=700,
                  title='Относительная частота событий в разрезе двух групп',
                 yaxis={'categoryorder':'total ascending'})
fig.show()

Можно заметить, что группа, которая не смотрела контакты contacts_show, чаще открывала карточки объявлений (advert_open), открывала карту объявлений (map), чаще добавляла объявления в избранное (favorites_add), а также чаще видела рекомендованные объявления (tips_show). Группа, смотревшая контакты contacts_show чаще кликала по рекомендованному объявлению (tips_click), чаще просматривала фотографии объявления (photos_show) и делали действие поиска (search_1).

Популярные сценарии , которые приводят к просмотру контактов¶

В разрезе сессий отберем популярные сценарии пользователей, которые приводят к совершению целевого действия - просмотр контактов contacts_show. А также построим диаграмму.

Для поиска сценариев до совершения целевого действия будем использовать созданную нами таблицу с пользователями, которые совершили целевое действие - contacts_show.

In [44]:
data_sorted = unique_events_df(data, 'session_id', 'event_name')
data_sorted.head()
Out[44]:
event_time event_name user_id date session_id session_duration
805 2019-10-07 13:39:45.989359 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 1 596.0
6541 2019-10-09 18:33:55.577963 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 2 507.0
6565 2019-10-09 18:40:28.738785 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 2 507.0
36412 2019-10-21 19:52:30.778932 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 3 899.0
36419 2019-10-21 19:53:38.767230 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 3 899.0

Построим диаграмму Сэнкей для отбора популярных сценариев пользователей до совершения ключевого действия contacts_show.

In [45]:
def add_features(df, add_target_2=False):
    
    """Функция генерации новых столбцов для исходной таблицы

    Args:
        df (pd.DataFrame): исходная таблица.
    Returns:
        pd.DataFrame: таблица с новыми признаками.
    """
    
    # сортируем по id и времени
    sorted_df = df.sort_values(by=['session_id', 'event_time']).copy()
    # добавляем шаги событий
    sorted_df['step'] = sorted_df.groupby('session_id').cumcount() + 1
    
    # добавляем узлы-источники и целевые узлы
    # узлы-источники - это сами события
    sorted_df['source'] = sorted_df['event_name']
    # добавляем целевые узлы
    sorted_df['target'] = sorted_df.groupby('session_id')['source'].shift(-1)
    if add_target_2:
        sorted_df['target_2'] = sorted_df.groupby('session_id')['source'].shift(-2)
    
    # возврат таблицы без имени событий
    return sorted_df.drop(['event_name'], axis=1)
In [46]:
table = add_features(data_sorted)
table.head()
Out[46]:
event_time user_id date session_id session_duration step source target
805 2019-10-07 13:39:45.989359 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 1 596.0 1 tips_show NaN
6541 2019-10-09 18:33:55.577963 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 2 507.0 1 map tips_show
6565 2019-10-09 18:40:28.738785 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 2 507.0 2 tips_show NaN
36412 2019-10-21 19:52:30.778932 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 3 899.0 1 tips_show map
36419 2019-10-21 19:53:38.767230 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 3 899.0 2 map tips_show
In [47]:
# удалим все пары source-target, шаг которых превышает 7
df_comp = table[table['step'] <= 7].copy().reset_index(drop=True)
In [48]:
def get_source_index(df):
    
    """Функция генерации индексов source

    Args:
        df (pd.DataFrame): исходная таблица с признаками step, source, target.
    Returns:
        dict: словарь с индексами, именами и соответсвиями индексов именам source.
    """
    
    res_dict = {}
    
    count = 0
    # получаем индексы источников
    for no, step in enumerate(df['step'].unique().tolist()):
        # получаем уникальные наименования для шага
        res_dict[no+1] = {}
        res_dict[no+1]['sources'] = df[df['step'] == step]['source'].unique().tolist()
        res_dict[no+1]['sources_index'] = []
        for i in range(len(res_dict[no+1]['sources'])):
            res_dict[no+1]['sources_index'].append(count)
            count += 1
            
    # соединим списки
    for key in res_dict:
        res_dict[key]['sources_dict'] = {}
        for name, no in zip(res_dict[key]['sources'], res_dict[key]['sources_index']):
            res_dict[key]['sources_dict'][name] = no
    return res_dict
In [49]:
source_indexes = get_source_index(df_comp)
In [50]:
def show_example(step, source_indexes=source_indexes):
    
    """Функция для вывода данных для конкретного шага будущей диаграммы

    Args:
        step (int): шаг.
        source_indexes (dict): словарь с данными по source на каждом шаге диаграммы
    Returns:

    """
    
    print(f'Пример подготовленных данных для шага {step}\n')
    for key in source_indexes[step]:
        print(f'{key}\n', source_indexes[step][key], '\n')
In [51]:
show_example(3)
Пример подготовленных данных для шага 3

sources
 ['tips_show', 'map', 'search_1', 'photos_show', 'search_6', 'contacts_call', 'contacts_show', 'advert_open', 'search_4', 'search_5', 'search_3', 'tips_click', 'favorites_add', 'search_2', 'search_7'] 

sources_index
 [29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43] 

sources_dict
 {'tips_show': 29, 'map': 30, 'search_1': 31, 'photos_show': 32, 'search_6': 33, 'contacts_call': 34, 'contacts_show': 35, 'advert_open': 36, 'search_4': 37, 'search_5': 38, 'search_3': 39, 'tips_click': 40, 'favorites_add': 41, 'search_2': 42, 'search_7': 43} 

In [52]:
def colors_for_sources(mode):
    
    """Генерация цветов rgba

    Args:
        mode (str): сгенерировать случайные цвета, если 'random', а если 'custom' - 
                    использовать заранее подготовленные
    Returns:
        dict: словарь с цветами, соответствующими каждому индексу
    """
    # словарь, в который сложим цвета в соответствии с индексом
    colors_dict = {}
    
    if mode == 'random':
        # генерим случайные цвета
        for label in df_comp['source'].unique():
            r, g, b = np.random.randint(255, size=3)            
            colors_dict[label] = f'rgba({r}, {g}, {b}, 1)'
            
    elif mode == 'custom':
        # присваиваем ранее подготовленные цвета
        colors = requests\
        .get('https://raw.githubusercontent.com/rusantsovsv/senkey_tutorial/main/json/colors_senkey.json').json()
        for no, label in enumerate(df_comp['source'].unique()):
            colors_dict[label] = colors['custom_colors'][no]
            
    return colors_dict
In [53]:
colors_dict = colors_for_sources(mode='custom')
In [54]:
# пересчитаем количестов юзеров в процентах от входа
def percent_users(sources, targets, values):
    
    """
    Расчет уникальных id в процентах (для вывода в hover text каждого узла)
    
    Args:
        sources (list): список с индексами source.
        targets (list): список с индексами target.
        values (list): список с "объемами" потоков.
        
    Returns:
        list: список с "объемами" потоков в процентах
    """
    
    # объединим источники и метки и найдем пары
    zip_lists = list(zip(sources, targets, values))
    
    new_list = []
    
    # подготовим список словарь с общим объемом трафика в узлах
    unique_dict = {}
    
    # проходим по каждому узлу
    for source, target, value in zip_lists:
        if source not in unique_dict:
            # находим все источники и считаем общий трафик
            unique_dict[source] = 0
            for sr, tg, vl in zip_lists:
                if sr == source:
                    unique_dict[source] += vl
                    
    # считаем проценты
    for source, target, value in zip_lists:
        new_list.append(round(100 * value / unique_dict[source], 1))
    
    return new_list
In [55]:
def lists_for_plot(source_indexes=source_indexes, colors=colors_dict, frac=10):
    
    """
    Создаем необходимые для отрисовки диаграммы переменные списков 
    
    Args:
        source_indexes (dict): словарь с именами и индексами source.
        colors (dict): словарь с цветами source.
        frac (int): ограничение на минимальный "объем" между узлами.
        
    Returns:
        dict: словарь со списками, необходимыми для диаграммы.
    """
    
    sources = []
    targets = []
    values = []
    labels = []
    link_color = []
    link_text = []

    # проходим по каждому шагу
    for step in tqdm(sorted(df_comp['step'].unique()), desc='Шаг'):
        if step + 1 not in source_indexes:
            continue

        # получаем индекс источника
        temp_dict_source = source_indexes[step]['sources_dict']

        # получаем индексы цели
        temp_dict_target = source_indexes[step+1]['sources_dict']

        # проходим по каждой возможной паре, считаем количество таких пар
        for source, index_source in tqdm(temp_dict_source.items()):
            for target, index_target in temp_dict_target.items():
                # делаем срез данных и считаем количество id            
                temp_df = df_comp[(df_comp['step'] == step)&(df_comp['source'] == source)&(df_comp['target'] == target)]
                value = len(temp_df)
                # проверяем минимальный объем потока и добавляем нужные данные
                if value > frac:
                    sources.append(index_source)
                    targets.append(index_target)
                    values.append(value)
                    # делаем поток прозрачным для лучшего отображения
                    link_color.append(colors[source].replace(', 1)', ', 0.2)'))
                    
    labels = []
    colors_labels = []
    for key in source_indexes:
        for name in source_indexes[key]['sources']:
            labels.append(name)
            colors_labels.append(colors[name])
            
    # посчитаем проценты всех потоков
    perc_values = percent_users(sources, targets, values)
    
    # добавим значения процентов для howertext
    link_text = []
    for perc in perc_values:
        link_text.append(f"{perc}%")
    
    # возвратим словарь с вложенными списками
    return {'sources': sources, 
            'targets': targets, 
            'values': values, 
            'labels': labels, 
            'colors_labels': colors_labels, 
            'link_color': link_color, 
            'link_text': link_text}
In [56]:
data_for_plot = lists_for_plot()
Шаг:   0%|          | 0/7 [00:00<?, ?it/s]
  0%|          | 0/14 [00:00<?, ?it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
  0%|          | 0/15 [00:00<?, ?it/s]
In [57]:
def plot_senkey_diagram(data_dict=data_for_plot):    
    
    """
    Функция для генерации объекта диаграммы Сенкей     
    Args:
        data_dict (dict): словарь со списками данных для построения.        
    Returns:
        plotly.graph_objs._figure.Figure: объект изображения.
    """
    
    fig = go.Figure(data=[go.Sankey(
        domain = dict(
          x =  [0,1],
          y =  [0,1]
        ),
        orientation = "h",
        valueformat = ".0f",
        node = dict(
          pad = 50,
          thickness = 15,
          line = dict(color = "black", width = 0.1),
          label = data_dict['labels'],
          color = data_dict['colors_labels']
        ),
        link = dict(
          source = data_dict['sources'], # indices correspond to labels, eg A1, A2, A1, B1, ...
          target = data_dict['targets'],
          value = data_dict['values'],
          label = data_dict['link_text'],
          color = data_dict['link_color']
      ))])
    fig.update_layout(title_text="Sankey Diagram", font_size=10, width=1000, height=900)
    
    # возвращаем объект диаграммы
    return fig
In [58]:
senkey_diagram = plot_senkey_diagram()
In [59]:
senkey_diagram.show()

На диаграмме видно множество сценариев до целевого действия - просмотр контактов, отберем 4 популярных сценария пользователя и построим воронки вовлечения пользователей до целевого действия.

Выберем следующие сценарии: 1) Tips_show -> contacts_show;

2) Photos_show -> contacts_show;

3) Search_1 -> photos_show -> contacts_show;

4) Map -> tips_show -> contacts_show.

Построение воронок¶

Сделаем таблицы, в которых будет отображаться какое количество уникальных пользователей, которые совершают события сценария. Далее построим воронки и посмотрим сколько пользователей прошли от начала сценария до целевого действия contacts_show.

In [60]:
data_sorted_new = add_features(data_sorted, add_target_2=True)
data_sorted_new.head()
Out[60]:
event_time user_id date session_id session_duration step source target target_2
805 2019-10-07 13:39:45.989359 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 1 596.0 1 tips_show NaN NaN
6541 2019-10-09 18:33:55.577963 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 2 507.0 1 map tips_show NaN
6565 2019-10-09 18:40:28.738785 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 2 507.0 2 tips_show NaN NaN
36412 2019-10-21 19:52:30.778932 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 3 899.0 1 tips_show map tips_show
36419 2019-10-21 19:53:38.767230 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 3 899.0 2 map tips_show map
In [61]:
def funnel_scenario_table(df, funnel_dict, funnel_by):
    """
    Функция принимает датафрейм, словарь в котором указан сценарий и название столбца по которому 
    необходимо делать подсчет. 
    Возвращает датафрейм в котором 2 столбца: наименование события и количество уникальных пользователей, 
    совершивших это событие.
    """
    funnel_columns = ['name', 'result', 'part']
    funnel_names = []
    funnel_results = []
    query = '{} == "{}"'
    for key in funnel_dict:
        query = query.format(key, funnel_dict[key])
        funnel_names.append(funnel_dict[key])
        funnel_results.append(df.query(query)[funnel_by].nunique())
        query += ' and {} == "{}"'
    
    data = {
        'name': funnel_names,
        'result': funnel_results
    }
    funnel_df = pd.DataFrame().from_dict(data)
    
    return funnel_df
In [62]:
def funnel_graphic(y, x): 
    """
    функция строит график воронку событий, количества пользователей 
    и процент пользователей перешедших с одного шага на другой
    """
    fig = go.Figure(go.Funnel(y=y,
                              x=x,
                              textposition='inside',
                              textinfo='value + percent previous',
                              textfont_size=14,
                              marker={"color":['#ed7953', '#fb9f3a', '#fdca26', '#f0f921']}
                             ))
    fig.update_layout(
        title={'text':'Воронка событий',
               'y':0.9,
               'x':0.55,
               'xanchor':'center',
               'yanchor':'top'})
    fig.show()
In [63]:
# Tips_show -> contacts_show;
scenario_1 = funnel_scenario_table(data_sorted_new , {'source': 'tips_show', 'target': 'contacts_show'}, 'user_id')
scenario_1.columns = ['Наименование события', 'Количество событий']
scenario_1
Out[63]:
Наименование события Количество событий
0 tips_show 2801
1 contacts_show 434
In [64]:
funnel_graphic(scenario_1['Наименование события'], scenario_1['Количество событий'])

Из всего количества уникальных пользователей совершивших действие tips_show на шаг с событием contacts_show перешло всего 15%.

In [65]:
#Photos_show -> contacts_show
scenario_2 = funnel_scenario_table(
    data_sorted_new , 
    {'source': 'photos_show', 'target': 'contacts_show'}, 
    'user_id'
)
scenario_2.columns = ['Наименование события', 'Количество событий']
scenario_2
Out[65]:
Наименование события Количество событий
0 photos_show 1095
1 contacts_show 188
In [66]:
funnel_graphic(scenario_2['Наименование события'], scenario_2['Количество событий'])

Из общего количества пользователей, двигающихся по 2 сценарию, после действия photos_show действие contacts_show совершило 17%.

In [67]:
# Search_1 -> photos_show -> contacts_show
scenario_3 = funnel_scenario_table(
    data_sorted_new , 
    {'source': 'search_1', 'target': 'photos_show', 'target_2': 'contacts_show'}, 
    'user_id'
)
scenario_3.columns = ['Наименование события', 'Количество событий']
scenario_3
Out[67]:
Наименование события Количество событий
0 search_1 787
1 photos_show 466
2 contacts_show 55
In [68]:
funnel_graphic(scenario_3['Наименование события'], scenario_3['Количество событий'])

Из 787 пользователей, совершивших событие search-1 только 12% дошло до целевого действия.

In [69]:
# Map -> tips_show -> contacts_show.
scenario_4 = funnel_scenario_table(
    data_sorted_new, 
    {'source': 'map', 'target': 'tips_show', 'target_2': 'contacts_show'}, 
    'user_id'
)
scenario_4.columns = ['Наименование события', 'Количество событий']
scenario_4
Out[69]:
Наименование события Количество событий
0 map 1456
1 tips_show 937
2 contacts_show 99
In [70]:
funnel_graphic(scenario_4['Наименование события'], scenario_4['Количество событий'])

Из 1456 пользователей совершивших событие map до целевого действия contacts_show дошло 11% пользователей.

Среди всех сценариев наиболее благоприятным является сценарий photos_show -> contacts_show по нему с первого шага до целевого шага перешел больший процент пользователей: 17%.

Проверка гипотез¶

Проверка 1й гипотезы¶

Проверим гипотезу о том, что конверсия в просмотры контактов различается у группы пользователей, которые совершают действия tips_show и tips_click и группы, которая совершает только tips_show.

Для проверки гипотезы необходимо будет сравнить конверсии по каждому событию в каждой из двух групп (в группе пользователей совершивших tips_show и tips_click и группе, которая совершила только tips_show), узнать отличаются ли эти доли или же мы можем утверждать, что между ними нет разницы.

Для этого сформируем нулевую и альтернативную гипотезы:

  • Нулевая: Конверсия в просмотры контактов у двух групп одинаковая;
  • Альтернативная: Конверсия в просмотры контактов у двух групп разная.

Для начала сформируем таблицу с двумя группами. В первую группу будут входить пользователи, которые совершили и tips_show и tips_click, а во втоорую будут входить пользователи, которые совершили tips_show и не совершали tips_click.

In [71]:
def hypo_groups_df(df, col1, col2):
    """
    Функция создает два датафрейма, в первом - пользователи, которые совершили оба действия, которые передаем в функцию
    Второй датафрейм содержит информацию о пользователях, которые совершили первое действие, но не совершили второе
    """
    pd.options.mode.chained_assignment = None
    temp = pd.DataFrame()
    temp = df.copy()
    temp = temp.groupby('user_id')['event_name'].unique().reset_index()

    #Отберем всех пользователей которые совершали col1
    temp['index'] = temp['event_name'].apply(lambda x: np.where(x==col1)).explode().explode()
    temp = temp.query('index.notnull()')

    #Разделим этих пользователей на тех кто также совершал col1 и тех, кто не совершал col2
    temp['index_2'] = temp['event_name'].apply(lambda x: np.where(x==col2)).explode().explode()
    #group_first будет содержать пользователей, которые сделали и col1 и favorites_add
    #group_second будет содержать пользователей, которые сделали col1 и не сделали col2
    group_first = temp.query('index_2.notnull()')
    group_second = temp.query('index_2.isnull()')

    #Удаляем промежуточные столбы и добавляем столбцы из оригинальной таблицы data
    group_first = group_first.drop(columns=['event_name', 'index', 'index_2'])
    group_first = group_first.merge(df, how='left', on='user_id')

    group_second = group_second.drop(columns=['event_name', 'index', 'index_2'])
    group_second = group_second.merge(df, how='left', on='user_id')

    # #Добавляем в таблицы столбцы source и target
    g_first = add_features(group_first, add_target_2=True)
    g_second = add_features(group_second, add_target_2=True)

    display(g_first.head().style.set_caption("Таблица пользователей, которые сделали и {} и {}".format(col1, col2)),\
            g_second.head().style.set_caption\
            ("Таблица пользователей, которые сделали {} и не сделали {}".format(col1, col2)))
    return g_first, g_second
In [72]:
group_one, group_two = hypo_groups_df(data_sorted, "tips_show", "tips_click")
Таблица пользователей, которые сделали и tips_show и tips_click
  user_id event_time date session_id session_duration step source target target_2
0 01147bf8-cd48-49c0-a5af-3f6eb45f8262 2019-11-01 21:13:53.377582 2019-11-01 00:00:00 59 4148.000000 1 tips_show tips_click tips_show
1 01147bf8-cd48-49c0-a5af-3f6eb45f8262 2019-11-01 22:20:20.323377 2019-11-01 00:00:00 59 4148.000000 2 tips_click tips_show tips_click
2 01147bf8-cd48-49c0-a5af-3f6eb45f8262 2019-11-01 22:20:25.502309 2019-11-01 00:00:00 59 4148.000000 3 tips_show tips_click tips_show
3 01147bf8-cd48-49c0-a5af-3f6eb45f8262 2019-11-01 22:22:56.152742 2019-11-01 00:00:00 59 4148.000000 4 tips_click tips_show nan
4 01147bf8-cd48-49c0-a5af-3f6eb45f8262 2019-11-01 22:23:01.365577 2019-11-01 00:00:00 59 4148.000000 5 tips_show nan nan
Таблица пользователей, которые сделали tips_show и не сделали tips_click
  user_id event_time date session_id session_duration step source target target_2
0 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-07 13:39:45.989359 2019-10-07 00:00:00 1 596.000000 1 tips_show nan nan
1 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 18:33:55.577963 2019-10-09 00:00:00 2 507.000000 1 map tips_show nan
2 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-09 18:40:28.738785 2019-10-09 00:00:00 2 507.000000 2 tips_show nan nan
3 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 19:52:30.778932 2019-10-21 00:00:00 3 899.000000 1 tips_show map tips_show
4 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 2019-10-21 19:53:38.767230 2019-10-21 00:00:00 3 899.000000 2 map tips_show map
In [73]:
group_one = funnel_scenario_table(
    group_one, 
    {'source': 'tips_show', 'target': 'tips_click', 'target_2': 'contacts_show'}, 
    'user_id'
)
group_one
Out[73]:
name result
0 tips_show 297
1 tips_click 267
2 contacts_show 2
In [74]:
#Формируем иаблицу с пользователями, которые совершили действие tips_show и не совершили tips_click
group_two = funnel_scenario_table(
    group_two, 
    {'source': 'tips_show', 'target': 'contacts_show'}, 
    'user_id'
)
group_two
Out[74]:
name result
0 tips_show 2504
1 contacts_show 358
In [75]:
def check_hypothesis(g1, g2):   
    """
    Функция проверяет гипотезы
    """
    #Уровень статистической значимости
    alpha = 0.05
    
    #Число пользователей совершивших события по группам
    successes = np.array([g1.iloc[len(g1) - 1]['result'],
                         g2.iloc[len(g2) - 1]['result']])
    
    #Общее кол-во пользователей в группах
    number_of_users = np.array([g1.iloc[0]['result'],
                               g2.iloc[0]['result']])
    
    print(successes, number_of_users)  # КОД РЕВЬЮЕРА
    
    #Пропорции успехов в 1 и 2 группах
    p1 = successes[0] / number_of_users[0]
    p2 = successes[1] / number_of_users[1]
    
    #Пропорция успехов в комбинированном датафрейме
    p_combined = (successes[0] + successes[1]) / (number_of_users[0] + number_of_users[1])
    
    #Разница пропорций
    difference = p1 - p2
    
    #Считаем статистику в ст. отклонениях стандартного норм. распределения
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) *\
                                    (1 / number_of_users[0] + 1 / number_of_users[1]))
    #Задаем стандартное нормальное распределение
    distr = st.norm(0, 1)
    
    #Если бы пропорции были равны, разница между ними = 0, т.к. распределение норм., вызовем метод
    #cdf(), модуль abs() т.к. тест двусторонний, по этой же причине удваиваем результат
    p_value = (1 - distr.cdf(abs(z_value))) * 2
    
    print('p-значение:', p_value)
    if p_value < alpha:
        print('Отвергаем нулевую гипотезу: между конверсиями есть разница')
    else:
        print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать конверсии разными')
In [76]:
check_hypothesis(group_one, group_two)
[  2 358] [ 297 2504]
p-значение: 3.288413985558236e-11
Отвергаем нулевую гипотезу: между конверсиями есть разница

Значение p-value достаточно маленькое и маловероятно, что наблюдаемые закономерности между группами - результат случайных процессов, значит, конверсия в просмотры контактов у группы пользователей, которые совершают действия tips_show и tips_click и группы, которая совершает только tips_show и не совершает tips_click разная.

Проверка 2й гипотезы¶

Так как большое количество пользователей совершает advert_open, хочется проверить, как просмотр объявления связан с другими событиями и влияет ли это на конверсию. Поэтому проверим гипотезу о том, что конверсия в просмотры контактов различается у группы пользователей, которые совершили advert_open и photos_show и группы, которая совершила только advert_open.

Для этого сформируем нулевую и альтернативную гипотезы:

  • Нулевая: Конверсия в просмотры контактов у двух групп одинаковая;
  • Альтернативная: Конверсия в просмотры контактов у двух групп разная.

Сформируем таблицу с двумя группами. В первую группу будут входить пользователи, которые совершили и advert_open и photos_show, а во втоорую будут входить пользователи, которые совершили advert_open и не совершали photos_show.

In [77]:
group_ones, group_twos = hypo_groups_df(data_sorted, "advert_open", "photos_show")
Таблица пользователей, которые сделали и advert_open и photos_show
  user_id event_time date session_id session_duration step source target target_2
0 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:34:33.849769 2019-10-19 00:00:00 5 739.000000 1 search_1 photos_show nan
1 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:40:38.990477 2019-10-19 00:00:00 5 739.000000 2 photos_show nan nan
2 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:58:00.109019 2019-10-19 00:00:00 6 115.000000 1 search_1 photos_show nan
3 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-19 21:59:54.637098 2019-10-19 00:00:00 6 115.000000 2 photos_show nan nan
4 00157779-810c-4498-9e05-a1e9e3cedf93 2019-10-20 18:49:24.115634 2019-10-20 00:00:00 7 0.000000 1 search_1 nan nan
Таблица пользователей, которые сделали advert_open и не сделали photos_show
  user_id event_time date session_id session_duration step source target target_2
0 004690c3-5a84-4bb7-a8af-e0c8f8fca64e 2019-10-18 22:14:05.555052 2019-10-18 00:00:00 20 215.000000 1 search_7 search_5 map
1 004690c3-5a84-4bb7-a8af-e0c8f8fca64e 2019-10-18 22:14:16.960831 2019-10-18 00:00:00 20 215.000000 2 search_5 map nan
2 004690c3-5a84-4bb7-a8af-e0c8f8fca64e 2019-10-18 22:17:40.719687 2019-10-18 00:00:00 20 215.000000 3 map nan nan
3 004690c3-5a84-4bb7-a8af-e0c8f8fca64e 2019-10-20 17:47:18.569612 2019-10-20 00:00:00 21 84.000000 1 search_7 search_4 search_6
4 004690c3-5a84-4bb7-a8af-e0c8f8fca64e 2019-10-20 17:47:19.889629 2019-10-20 00:00:00 21 84.000000 2 search_4 search_6 search_5
In [78]:
#Формируем таблицу с пользователями, которые совершили действия advert_open и favorites_add
group_ones = funnel_scenario_table(
    group_ones, 
    {'source': 'advert_open', 'target': 'photos_show', 'target_2': 'contacts_show'}, 
    'user_id'
)
group_ones
Out[78]:
name result
0 advert_open 73
1 photos_show 52
2 contacts_show 5
In [79]:
#Формируем таблицу с пользователями, которые совершили действия advert_open
group_twos = funnel_scenario_table(
    group_twos, 
    {'source': 'advert_open', 'target': 'contacts_show'}, 
    'user_id'
)
group_twos
Out[79]:
name result
0 advert_open 678
1 contacts_show 25
In [80]:
check_hypothesis(group_ones, group_twos)
[ 5 25] [ 73 678]
p-значение: 0.1899321346713685
Не получилось отвергнуть нулевую гипотезу, нет оснований считать конверсии разными

p-value получилось больше статистической значимости, значит, можем счтитать, что конверсия у двух групп, которые совершали просмотр карточки объявления и просмотр фото, и которые совершали только просмотр карточки объявления, одинаковая.

Выводы и рекомендации¶

В данном проекте было проанализированно поведение пользователей приложения "Ненужные вещи". Были отобраны популярные сценарии, по которым двигаются пользователи до достижения целевого действия, а также построены воронки с конверсией пользователей на каждом шаге сценариев. Была расчитана относительная частота событий в разрезе двух групп: которые смотрели контакты и которые не смотрели контакты.

Была проведена предобработка данных, изменены названия столбцов и типы данных, была проведена проверка дубликатов, было выяснено за какой период представлены данные в датасете: в период с 2019-10-03 по 2019-11-07.

Было выяснено, в приложении 4293 уникальных пользователя и 15 событий. Также было найдено время суток, в которое пользователи приложения наиболее активны: с 14 до 16 часов дня, а также с 20 до 21 вечера.

Было выяснено, что событие, которое совершают уникальные пользователи чаще всего - tips_show, а также событие map и photos_show. Именно эти три события совершает больший процент уникальных пользователей приложения. Если смотреть на количество совершаемых событий в целом, а не только по уникальным пользователям, то самыми популярными событиями являются: tips_show - просмотрели 40055 раз, событие photos_show - 10012 раз, событие advert_open - 6164 раз.

Была найдена средняя длительность сессии пользователей - 11 минут.

Была найдена относительная частота событий в разрезе двух групп пользователей: которые совершали целевое действие просмотр контактов contacts_show и те, которые не совершали целевое событие contacts_show. Было замечено, что группа, которая не смотрела контакты contacts_show, чаще открывала карточки объявлений (advert_open), открывала карту объявлений (map), чаще добавляла объявления в избранное (favorites_add), а также чаще видела рекомендованные объявления (tips_show). Группа, смотревшая контакты contacts_show чаще кликала по рекомендованному объявлению (tips_click), чаще просматривала фотографии объявления (photos_show).

Были найдены наиболее популярные события пользователей в целевое действие - просмотр контактов. Такими сценариями оказались:

  • Tips_show -> contacts_show;
  • Photos_show -> contacts_show;
  • Search_1 -> photos_show -> contacts_show;
  • Map -> tips_show -> contacts_show.

Самая большая конверсия в целевое действие оказалась у сценария photos_show -> contacts_show, из 1095 пользователей в целевое действие перешло 17% из них.

Была проведена проверка гипотез:

  • 1я Гипотеза: конверсия в просмотры контактов различается у группы пользователей, которые совершают действия tips_show и tips_click и группы, которая совершает только tips_show и не совершили tips_click;
  • 2я Гипотеза: конверсия в просмотры контактов различается у группы пользователей, которые совершили advert_open и photos_show и группы, которая совершила только advert_open и не совершила photos_show;

При проверке первой гипотезы было выяснено, что конверсия в просмотры контактов у двух групп действительно различается. При проверке второй гипотезы было выяснено, что конверсия в целевое действие у двух групп одинаковая.

Рекомендации:

Для улучшения взаимодействия пользователей с приложением рекомендуется улучшить те области приложения, с которым пользователь взаимодействует чаще всего. Например, большое количество уникальных (1456, более 33%) пользователей пользуются map (просмотр карты), также большое количество пользователей, а именно 2801 уникальный пользователь, что составляет более 65% от всех уникальных пользователей, просматривают tips_show (рекомендованные объявления). Так как один из популярных сценариев в целевое действие: map -> tips_show -> contacts_show, то можно персонализировать объявления высвечивая клиенту рекомендованное объявление tips_show исходя из локаций которые он просматривает на карте. Таким образом, можно привлечь внимание пользователя к рекомендованным объявлениям в его регионе.

Также учитывая популярный сценарий: search_1 ->photos_show -> contacts_show можно расширить возможности поиска: добавить дополнительные фильтры, высвечивать сначала те объявления, которые содержат фотографии, так как конверсия в целевое действие выше, когда пользователь просматривает фото. Можно также рекомендовать пользователям добавлять больше фотографий в объявление, аргументируя тем, что это может повысить просмотры их объявлений.

Так как при проверке гипотез мы выяснили, что конверсия в целевое действие идентичная, если пользователь просматривает объявление advert_open и потом просматривает фото photos_show, и если пользователь только просматривает объявление advert_open. Тогда можно добавить дополнительные функции для продвижения объявлений таким образом, чтобы пользователь хотел просмотреть данное объявление, так как конверсия в целевое действие у них тоже будет высокой. Например, приоритизировать локацию объявления или рекомендовать эти объявления пользователю: "возможно, вам интересно...".

Также все вышеописанные действия можно адаптировать под время активности пользователя. Например, когда активность пользователей выше, вероятность того, что они увидят рекомендованные объявления тоже выше, поэтому возможно высвечивать пользователям рекомендации преимущественно с 14 до 16 дня и с 20 до 21 вечера. Это время приходится на обеденные перерывы и вечернее времяприпровождение пользователей. Добавлять нововведения в приложение можно ночью, когда активность пользователей наиболее низкая, а именно с 3 до 6 утра, чтобы не столкнуться с большим негативным опытом пользователей приложения при происхождении неполадок.

Выше описанные действия помогут нам вести пользователей по благоприятным сценариям, а также подкреплять позитивный опыт пользователей приложения, ведь улучшения затронут те области приложения, которыми пользуется большинство пользователей приложения и пользуются ими чаще всего.

Ссылка на презентацию:

https://github.com/leryash/graduation_project/blob/main/presentation.pdf